fix(webview): add durable per-view state base - #977
fix(webview): add durable per-view state base#977easonLiangWorldedtech wants to merge 14 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds durable per-view state schemas, identifiers, persistence, restoration, and state merging across webview and extension layers, with coverage for parallel instances, mode/API selections, pruning, launch handling, and local-state isolation. ChangesPer-view state persistence
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Webview
participant ExtensionStateContext
participant webviewMessageHandler
participant ClineProvider
participant globalState
Webview->>ExtensionStateContext: initialize viewStateId
ExtensionStateContext->>webviewMessageHandler: webviewDidLaunch with viewStateId
webviewMessageHandler->>ClineProvider: setViewStateId(viewStateId)
ClineProvider->>globalState: load and save viewStates
ClineProvider-->>Webview: merged per-view state
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/core/webview/ClineProvider.ts`:
- Around line 3005-3056: Update resetState(), activateProviderProfile(),
upsertProviderProfile(), and deleteProviderProfile() to clear or synchronize the
affected viewLocalState fields after mutating contextProxy. Reuse
_clearViewLocalState() for resetState() and _updateViewLocalStateFromMutation()
or equivalent targeted invalidation for profile changes, ensuring stale
currentApiConfigName and apiConfiguration values cannot mask the updated global
state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 58c5e801-f818-415c-b0de-9f69e7498604
📒 Files selected for processing (13)
packages/types/src/__tests__/index.test.tspackages/types/src/global-settings.tspackages/types/src/vscode-extension-host.tssrc/core/webview/ClineProvider.tssrc/core/webview/__tests__/ClineProvider.parallelMode.spec.tssrc/core/webview/__tests__/ClineProvider.spec.tssrc/core/webview/__tests__/ClineProvider.sticky-mode.spec.tssrc/core/webview/__tests__/webviewMessageHandler.spec.tssrc/core/webview/webviewMessageHandler.tswebview-ui/src/App.tsxwebview-ui/src/__tests__/App.spec.tsxwebview-ui/src/context/ExtensionStateContext.tsxwebview-ui/src/utils/vscode.ts
💤 Files with no reviewable changes (1)
- webview-ui/src/App.tsx
edelauna
left a comment
There was a problem hiding this comment.
Exciting to see this work come together! Had some implementation comments, and can we also add some ui testing:
We can leverage the UI testing setup established in McpServerRestriction.spec.tsx and webview-ui/src/utils/test-utils.tsx:
-
ExtensionStateContext.ProviderWrapper Pattern:
Re-use therenderWithStatepattern to mount webview components with specificviewStateIdprops and verify that UI components respond correctly to view-localmodeandcurrentApiConfigNamestate without global bleed. -
Reseed & Identity Tests:
Similar to the slug-change reseed tests inMcpServerRestriction.spec.tsx, add UI-level tests inExtensionStateContext.spec.tsxorApp.spec.tsxto verify that whenviewStateIdchanges or a webview reloads, local React state reseeds properly from the new view'sviewStateIdpayload. -
vscode.getViewStateId& Messaging Spies:
Ensure UI tests verifyVSCodeAPIWrapper.getViewStateId()fallback behavior whensessionStorage/localStorageare restricted or cleared.
| return viewStates | ||
| } | ||
|
|
||
| private async savePersistedViewState(values: Partial<PersistedViewState>): Promise<void> { |
There was a problem hiding this comment.
savePersistedViewState reads the global viewStates dictionary from contextProxy, mutates states[this.viewStateId] in memory, and writes it back asynchronously via await contextProxy.setValue("viewStates", ...). When concurrent webview instances update mode or API profile selections simultaneously, one instance reads stale global state before the other's write completes, causing a lost update on viewStates.
| @@ -2864,6 +3082,7 @@ export class ClineProvider | |||
| } | |||
|
|
|||
| await this.contextProxy.resetAllState() | |||
There was a problem hiding this comment.
resetState() clears global settings in contextProxy but does not clear this.viewLocalState (e.g., via _clearViewLocalState()). When getState(viewStateId) runs, it merges stale viewLocalState overrides over the reset contextProxy defaults, causing pre-reset or deleted profile settings to persist in active webview instances.
| * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in | ||
| * sync with global state changes that would otherwise be invisible behind mergedStateValues. | ||
| */ | ||
| private _updateViewLocalStateFromMutation(values: Partial<RooCodeSettings>): void { |
There was a problem hiding this comment.
_updateViewLocalStateFromMutation updates in-memory viewLocalState in response to setValue/setValues calls, but never invokes savePersistedViewState. Mutations made via setValue/setValues are held only in memory and lost when the webview reloads or VS Code restarts.
|
|
||
| describe("local state isolation", () => { | ||
| it("should isolate mode state between instances", async () => { | ||
| const provider1 = new ClineProvider( |
There was a problem hiding this comment.
The test 'should isolate mode state between instances' reads the initial mode of two provider instances without mutating mode in either, making it incapable of verifying whether mode changes in one instance leak to other instances.
| vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue("shared-profile") | ||
| vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) | ||
| }) | ||
|
|
There was a problem hiding this comment.
The webviewDidLaunch handler test passes viewStateId: 'view-1' but omits an assertion verifying that provider.setViewStateId was called with 'view-1'.
82f9f23 to
d724948
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts (1)
1098-1195: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding coverage for the two uncovered profile-mutation paths.
None of these tests exercise
upsertProviderProfile(..., false)(non-activating save) or adeleteProviderProfilecase whereviewLocalState.currentApiConfigNamediverges from the global value - both are the exact gaps flagged insrc/core/webview/ClineProvider.ts(upsertProviderProfile/deleteProviderProfile). Adding cases here would catch regressions on those fixes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts` around lines 1098 - 1195, The profile-mutation tests cover only activating upserts and matching delete state; add coverage for the two missing branches. In the “profile mutations” suite, add a test for upsertProviderProfile(..., false) that verifies the saved profile does not activate or incorrectly synchronize current state, and a deleteProviderProfile test where viewLocalState.currentApiConfigName differs from the global ContextProxy value, asserting the intended local-state behavior after deletion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts`:
- Around line 1098-1195: The profile-mutation tests cover only activating
upserts and matching delete state; add coverage for the two missing branches. In
the “profile mutations” suite, add a test for upsertProviderProfile(..., false)
that verifies the saved profile does not activate or incorrectly synchronize
current state, and a deleteProviderProfile test where
viewLocalState.currentApiConfigName differs from the global ContextProxy value,
asserting the intended local-state behavior after deletion.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1c33c4bc-cef3-4dc5-a6f1-07927265c1e5
📒 Files selected for processing (6)
src/core/webview/ClineProvider.tssrc/core/webview/__tests__/ClineProvider.parallelMode.spec.tssrc/core/webview/__tests__/webviewMessageHandler.spec.tswebview-ui/src/context/__tests__/ExtensionStateContext.spec.tsxwebview-ui/src/utils/__tests__/vscode.spec.tswebview-ui/src/utils/vscode.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/core/webview/tests/webviewMessageHandler.spec.ts
edelauna
left a comment
There was a problem hiding this comment.
Couple more comments - thanks for continuing to iterate on this.
| const state2 = await provider2.getState() | ||
|
|
||
| expect(state1.mode).toBe("architect") | ||
| expect(state2.mode).toBe("code") |
There was a problem hiding this comment.
Does this actually prove isolation? provider2 starts at "code" (the global default) and is never mutated, so this assertion passes whether or not the two instances share state. Would it be more meaningful to first set provider2 to a distinct non-default mode (e.g. "debugger"), then mutate provider1, then assert provider2 still reads "debugger"?
There was a problem hiding this comment.
Already addressed — the isolation test now sets the second provider to a distinct non-default mode before mutating the first provider. Specifically, provider2 is set to debugger, then provider1 is set to architect. The assertions then verify provider1 reads architect while provider2 still reads debugger.
So the test now proves instance isolation with two distinct non-default/local states instead of relying on the global default.
|
|
||
| expect(state.apiConfiguration.apiProvider).toBe("bedrock") | ||
| expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") | ||
| expect((provider as any).viewLocalState.apiConfiguration.apiProvider).toBe("bedrock") |
There was a problem hiding this comment.
The test confirms the new bedrock keys are present, but the PROVIDER_SETTINGS_KEYS merge at line 3118 of ClineProvider.ts spreads onto the existing viewLocalState.apiConfiguration — so stale keys from the previous provider (e.g. openRouterModelId) could survive. Worth adding expect((provider as any).viewLocalState.apiConfiguration).not.toHaveProperty("openRouterModelId") here?
There was a problem hiding this comment.
Fixed — added the stale-key assertion on line 1068, so the test now verifies that switching from OpenRouter to Bedrock removes the previous OpenRouter model key. The implementation already handles this on line 3143: when a flat provider-settings update includes the provider field, it replaces the local API configuration instead of merging into the existing one, so stale provider-specific keys cannot survive. Verified with the parallel-mode spec: 42 tests passed.
| const wrapper = new VSCodeAPIWrapper() | ||
|
|
||
| expect(wrapper.getViewStateId()).toBe("memory-view") | ||
| expect(wrapper.getViewStateId()).toBe("memory-view") |
There was a problem hiding this comment.
crypto.randomUUID is mocked to always return "memory-view", so calling getViewStateId() twice will return the same string regardless of whether the early-return guard (that reads the stored ID) is present or not. What if the mock returned a different value on the second call — would the second getViewStateId() still return "memory-view"?
There was a problem hiding this comment.
Fixed — the UUID mock now returns different values on subsequent calls (line 64). The assertions on lines 83-85 verify that the second call still returns "memory-view" and that UUID generation only happens once, so the test now proves the stored/in-memory ID is reused rather than regenerated. Verified: 3 tests passed.
|
|
||
| vi.mock("../../task-persistence", () => ({ | ||
| readApiMessages: vi.fn().mockResolvedValue([]), | ||
| saveApiMessages: vi.fn().mockResolvedValue(undefined), |
There was a problem hiding this comment.
The production code replaces non-alphanumeric/dash characters with underscores: .replace(/[^A-Za-z0-9_-]/g, "_"). All calls here use already-clean IDs — is there a test somewhere that passes a raw ID containing spaces, dots, or slashes to verify the sanitization actually runs and that the sanitized form is the key used in viewStates?
There was a problem hiding this comment.
Yes — there is coverage for this now. The test at ClineProvider.parallelMode.spec.ts passes a raw view state ID containing a slash, dot, and spaces: tab panel/with.dots and spaces. It then verifies that the persisted viewStates key is the sanitized form on ClineProvider.parallelMode.spec.ts, and also verifies the raw unsanitized key is not present on ClineProvider.parallelMode.spec.ts. The parallel-mode spec was verified passing with 42 tests.
| private async savePersistedViewState(values: Partial<PersistedViewState>): Promise<void> { | ||
| const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { | ||
| const states = this.getPersistedViewStates({ fresh: true }) | ||
| const current = states[this.viewStateId] ?? {} |
There was a problem hiding this comment.
this.viewStateId is read inside the .then() closure at execution time. If setViewStateId() fires between when this write is enqueued and when the queue runs it, the write would land under the new ID rather than the one that was active when the change was made. Should viewStateId be captured before the .then() (e.g. const viewStateId = this.viewStateId at the top of savePersistedViewState)?
There was a problem hiding this comment.
Yes — fixed. savePersistedViewState now captures the active viewStateId before the queued write is created on line 454, and the queued closure uses that captured ID on lines 457-458 instead of reading this.viewStateId later: ClineProvider.ts.
There is also regression coverage starting on line 1115: ClineProvider.parallelMode.spec.ts. The test starts a save under view-a on line 1139, switches to view-b before the queued write completes on line 1141, then verifies the write lands under view-a on lines 1145-1148: ClineProvider.parallelMode.spec.ts.
| if (value === undefined || value === null) { | ||
| delete this.viewLocalState[key] | ||
| } else { | ||
| this.viewLocalState[key] = value |
There was a problem hiding this comment.
viewLocalState is updated here before savePersistedViewState is awaited. If the durable write fails, the in-memory state is ahead of what was persisted — on next reload viewLocalState would be re-hydrated from the old globalState value. Is that acceptable, or should the in-memory update only happen after the persist succeeds?
There was a problem hiding this comment.
Good catch — I changed the ordering so the in-memory viewLocalState update only happens after durable persistence succeeds. The shared helper now awaits persistence first on line 3091, then updates in-memory state on line 3092: ClineProvider.ts.
I also added regression coverage starting on line 847: ClineProvider.parallelMode.spec.ts. The test forces the persisted viewStates write to fail on line 854, verifies saveViewState(...) rejects on line 858, and confirms viewLocalState was not advanced on line 859: ClineProvider.parallelMode.spec.ts.
| this.saveViewState("apiConfiguration", providerSettings), | ||
| ]) | ||
|
|
||
| this._updateViewLocalStateFromMutation({ |
There was a problem hiding this comment.
saveViewState("currentApiConfigName", name) at line 1823 already writes both keys into viewLocalState (line 561). Does _updateViewLocalStateFromMutation here need to repeat that write, or is it a no-op? The setValue/setValues path only calls _updateViewLocalStateFromMutation + _persistViewLocalStateFromMutation without the preceding saveViewState — seems like these two paths could be consolidated.
There was a problem hiding this comment.
Fixed. I consolidated the view-local state update/persist flow so profile activation no longer calls saveViewState(...) and then repeats the same local-state writes through the mutation sync path. Both activateProviderProfile and activated upsertProviderProfile now use the shared mutation helper directly, while setValue / setValues and saveViewState also route through the same helper.
I also added assertions that profile activation does not call saveViewState(...), which catches the duplicate path if it comes back.
| await write | ||
| } | ||
|
|
||
| private async clearPersistedViewState(viewStateId = this.viewStateId): Promise<void> { |
There was a problem hiding this comment.
clearPersistedViewState is defined here but I cannot find any call site for it. When a tab-panel ClineProvider is disposed, its viewStates entry is never cleaned up — it lives until the 50-entry prune evicts it by age. Should this be called from dispose()?
There was a problem hiding this comment.
Yes — fixed. dispose() now clears persisted view-local state for editor/tab providers on line 891: ClineProvider.ts. It is guarded to editor render contexts starting on line 889, so sidebar providers keep their stable persisted state: ClineProvider.ts.
I also added test coverage starting on line 1153: ClineProvider.parallelMode.spec.ts. The test seeds a viewStates entry, disposes the editor provider on line 1160, and verifies the persisted key is removed on line 1162: ClineProvider.parallelMode.spec.ts.
2ef16bb to
37a5dd1
Compare
edelauna
left a comment
There was a problem hiding this comment.
It would help my review if you could reply to the existing comments to indicate how they've been addressed.
2b9dd91 to
4a72069
Compare
Summary
Add the foundational per-view state infrastructure for parallel mode. This is the root PR that all subsequent parallel-mode PRs depend on.
Addresses old Issue #908 (root mechanism). Fixes persistence limitation by introducing registered global
viewStateswith bounded cleanup and proper ContextProxy hydration.Changes
webview_state_idviewLocalStatebuffer: Transient per-view state that merges with global state viagetState()layerviewStatespersistence: Registered global setting key storing only non-secret selections (mode,currentApiConfigName,updatedAt) — bounded to most recent 50 entriesKey Design Decisions
Durable per-view persistence via registered global
viewStatesPreviously, persistence relied on scattered dynamic
__view_state_*keys that were not formally registered. Now:viewStatesis a formal entry inglobalSettingsSchemaand included inGLOBAL_STATE_KEYSContextProxy.initialize()on startup, then hydrated throughloadViewState()which resolves profile settings viaProviderSettingsManager.getProfile()modeandcurrentApiConfigName. FullapiConfiguration(including API keys/secrets) is NOT stored in globalState.Tests
Related
Summary by CodeRabbit
New Features
viewStatessetting, including per-view isolation for mode and API config selection.viewStateIdso each webview instance hydrates the correct persisted state after launch/reload.Bug Fixes
apiKey) from being persisted in durable per-view storage.Tests